Skip to content

fix: motion planning detours & hysteresis, custom referee rules, and benchmark metrics - #147

Merged
isaac0804 merged 4 commits into
mainfrom
fix/motion-planning-referee-tactics
Oct 19, 2026
Merged

isaac0804 merged 4 commits into
mainfrom
fix/motion-planning-referee-tactics

Conversation

@isaac0804

Copy link
Copy Markdown
Contributor

Summary

Comprehensive audit and stabilization across motion planning, referee overrides, and tactic partitioning:

1. Motion Planning

  • Detour Memory Keying (FastPathPlanner): Keyed detour memory on obstacle identity rather than contact points, eliminating dithering oscillations against static and dynamic obstacles.
  • Speed Modulation & Angular Penalties (TrajSample): Added temporal speed-modulation candidates (0.75x, 0.55x) and Sumatra-style angular deviation penalties. On crossing, completion time dropped from 5.10s -> 3.43s (-1.67s), path ratio dropped from 1.55x -> 1.09x, and brake events dropped from 3.0 -> 0.0.
  • Forward Candidate Pruning (TrajSample): Restricted intermediate candidate generation to the forward/lateral hemisphere (<= 90 deg), removing an erroneous 180-deg backward candidate that caused robots to reverse away from dynamic pinches (pinching_contest time dropped from 4.68s -> 2.57s and path ratio from 1.26x -> 1.06x).
  • Benchmark Metrics: Surfaced excess_distance_m and near_miss_ratio (% of moving ticks where clearance < 0.15m) across results JSON, markdown tables, and terminal reporting.

2. Custom Referee Rules & Actions

  • Keep-Out Clamping: Clamped restart positioning outside the 0.80m keep-out arc to prevent 0.79m boundary precision violations.
  • Penalty Positioning: Positioned defending penalty goalkeepers with +r_robot offset on the defense line and attacking keepers on their own goal line.
  • Crashing Rule: Avoided penalizing separating robots when relative crash speed <= threshold.
  • Double Touch: Fixed attribution of double-touch fouls to the last kicker and registered indirect free kicks in two-stage restarts.

3. Strategy & Tactics Hysteresis

  • Closer-to-Ball Hysteresis: Added margin-based hysteresis to friendly_closer_to_ball to prevent rapid 60Hz role-swapping between equidistant robots.
  • Activation Bounds: Widened spatial thresholds in ClearBallTactic (2.6m -> 3.0m) and PressAndContainTactic (1.4m -> 1.7m) to prevent boundary flickering; added Sticky[int] hysteresis to presser selection.
  • Deadlock Timeout: Added stall recovery timeout to DecoyOverloadTactic.
  • State Cleanliness: Moved hysteresis state to tactic instances with clean reset_applicable() and test teardown hooks.

Test Verification

  • pixi run lint: 100% clean (black, ruff, isort).
  • pixi run test: 12,885 passed, 83 xfailed, 0 failed.
  • Motion planning benchmark: 29/29 scenarios passing for trajsample.
  • Unit regression tests added for all fixed boundary conditions.

…e detours

- FastPathPlanner (C++ and Python): key obstacle detour memory on obstacle ID
  rather than shifting contact points, eliminating dithering oscillations.
- TrajectorySamplingPlanner: add temporal speed-modulation candidates (0.75x,
  0.55x) and Sumatra-style angular deviation penalty, cutting crossing detour
  from 1.55x (5.10s) to 1.09x (3.43s) with zero brake events.
- TrajectorySamplingPlanner: constrain candidate targets to forward/lateral
  hemisphere (<= 90 deg), eliminating the 180-deg backward candidate that caused
  robots to reverse away from dynamic pinches (pinching_contest 4.68s -> 2.57s).
- Update visual comparisons and motion planning documentation.
- Add excess_distance_m (path length minus direct Euclidean distance) and
  near_miss_ratio (% of moving ticks where clearance < 0.15m) to BenchmarkManager.
- Record both metrics in results JSON, aggregate summaries, and Markdown report.
- Include excess distance (+X.XXm) in terminal progress printout.
… and crashing rule

- actions.py: clamp restart positions outside 0.80m keep-out arc (fixing 0.79m
  floating-point precision boundary penalty).
- actions.py: place defending penalty goalkeeper +ROBOT_RADIUS offset on defense line
  and attacking keeper on own goal line.
- crashing_rule.py: do not penalize separating robots when relative crash speed <= threshold.
- double_touch_rule.py: correctly attribute double-touch fouls to the last kicker.
- Add regression tests in test_custom_referee, test_pushing_crashing, and test_referee_unit.
…and tactics

- pickers.py: add margin-based hysteresis to friendly_closer_to_ball to prevent
  rapid 60Hz role-swapping when robots are equidistant to the ball.
- ClearBallTactic: expand activation thresholds (friendly distance 2.6m -> 3.0m,
  enemy distance 1.2m -> 1.5m) to prevent boundary flickering.
- PressAndContainTactic: widen enemy distance threshold (1.4m -> 1.7m) and use
  Sticky[int] hysteresis for presser robot selection.
- DecoyOverloadTactic: add stall/deadlock timeout when no friendly targets are reachable.
- Engine/tactics: store all hysteresis state on tactic instances with reset_applicable()
  and module-level reset hooks for clean test isolation.
- Add comprehensive regression tests in test_strategy, test_friendly_closer_to_ball,
  test_clear_ball_tactic, test_press_and_contain_tactic, and test_decoy_and_overload_tactic.
@isaac0804 isaac0804 added bug Something isn't working release:patch Fixes and small tweaks to main. labels Oct 18, 2026
@isaac0804
isaac0804 merged commit 0ce4e2a into main Oct 19, 2026
4 of 5 checks passed
@isaac0804
isaac0804 deleted the fix/motion-planning-referee-tactics branch October 19, 2026 12:11
isaac0804 added a commit that referenced this pull request Oct 26, 2026
…ashboard for the field (#148)

* docs: refresh the roadmap against the code, and the stale notes it points at

The roadmap still described the pre-evaluate.py evaluation plan, kept a
mirror_swap xfail that no longer exists, and listed several items that had
already shipped. Rewritten along its own convention (a resolved item becomes a
one-line pointer under "Done"), with the remaining items numbered in sequence:

- the strategy-evaluation items -> Done: evaluate.py and pool_v1 replaced the
  situation bench and ladder.py. The two genuinely open parts moved instead of
  disappearing: the CI stall gate into the stalls entry, the tactic contracts
  into the ball-holding entry.
- directional tube and Replay overlays -> Done; fpp mirror_swap -> Done: the
  0.52 m was the defense-area keep-out clamp, not a local planner minimum, and
  the xfail went in 5f97199.
- stalls -> the 2026-10-17 full round-robin figures; sim realism -> Phases 0-2
  built, with what remains named.
- bang-bang, trajsample, testing gaps and convergence revised for what has
  landed since; the unrecorded Sumatra findings (#4-7) and the shared-Sticky
  item removed (Sticky exists and is used).

Also fixes the stale notes that pointed into the roadmap: obstacles.py's class
docstring still described a growing circle, FORK_NOTES referenced two deleted
roadmap sections, sim_realism's status line read as if nothing had been built,
and testing_gaps #6 now records the one sim case for
ball_placement_interference.

* docs: fix the roadmap refresh's errors; cite roadmap entries by title

Corrections to the previous commit, checked against the code:
- stalls: 1731e90 fixed only the kick-off that waited the 15 s cap; the
  three COMMITTED_FROZEN stalls of the 2026-10-17 round-robin are not yet
  diagnosed (docs/strategies.md says the same).
- sim realism: P1's value model is fitted (passes 1-3) but not yet a better
  ranker than goals; only P2 is not started.
- Shared hysteresis helper: Sticky's users are PassAndShoot, ClearBall and
  PressAndContain; pickers.friendly_closer_to_ball keeps its own hysteresis.
- grsim entry: drop the note that FORK_NOTES points at a missing section
  (the previous commit fixed it); "like 10c" names its entry; pool_v1 holds
  the v3 ranges, v3_seed2 is the round-robin's seeded run of them.

The roadmap now asks to cite entries by title, since open items renumber.
Every comment, docstring and doc that cited a roadmap number used numbers
long gone ("roadmap item 15/16", "item 5", "item 1"); each now names its
entry. fastpathplanner_corner_test's docstring said seeds were pinned as
stuck; the list has been empty since #147.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(engine): skip the pinned goalkeeper when its robot is not on the field

A team with no robot of goalkeeper_id (default 0) crashed on its first tick
with KeyError in the goalkeeper tactic. Real tests often run without robot 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(tactics): hysteresis on the shot aim in LeadAndSupport and GiveAndGo

Against a centred keeper the two corners are near-equal, and the aim flipped
between them every tick; a lone carrier turned back and forth ~11 s before
shooting. Uses find_best_shot's switch margin, as _pass_and_score does.
Changes strategy code: cached matches rerun.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(examples): one attacker vs one defender on real robots or in sim

Field, camera half, vision/firmware IDs, IR trust and tactics in one config
block; operator-only referee driven from the dashboard; starts in HALT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(engine): no goalkeeper row in debug_status when its robot is absent

A team without the goalkeeper's robot showed a phantom goalkeeper (e.g. ID 15)
in the dashboard's tactics list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(run): field check on camera runs, auto-HALT on real robots

FieldCheck judges raw vision, the geometry packet and radio feedback: each
driven robot seen, under its ID, inside field_bounds; camera sending; field
size; radio still answering; unconfigured robots in play. The Live tab shows
the rows, dims everything outside field_bounds and draws the field as
ssl-vision sees it. On real robots any failing row forces HALT. The dashboard
opens on Live when a match is attached; Space only halts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(run): robot profiles: push without a kicker, gentle carry without a dribbler

RobotProfile(kicker, dribbler, ir) per robot, passed to StrategyRunner as
{yellow,blue}_robot_profiles and applied in every mode, so a test with a
broken robot can be rehearsed in a simulator. ProfiledRobotController wraps
the team's robot controller: a kick becomes a 0.3 s forward push at 1 m/s
(a stop command ends it); a dribble request without a dribbler never backs
up and is scaled to 0.5 m/s and 2 rad/s together. A robot without a ball
sensor is left out of the trusted-IR set. A profile for a robot that is not
driven, or ball sensors given both ways, stops the run at startup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(examples,dashboard): per-robot hardware and RULES switch; tidier Live page

The attacker/defender example takes one TestRobot per robot (vision ID,
firmware ID, RobotProfile) and RULES = "off" | "on" ("on": the human
referee profile, nothing auto-advances). The Live page lists only failing
and warning field-check rows, folds the passing ones into an "N ok" line,
hides the restart buttons when no rule sets up a restart, and drops the
robot list the field check already covers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(run,analysis): record field tests as SSL game logs; measure robots from them

StrategyRunner(game_log_path=...) records a camera run in the league's
game-log format: every vision packet as received, the referee's state,
the radio's bytes both ways and a run-info record (ID mappings,
profiles), all on this machine's clock. tools/convert_ssl_log.py reads
it unchanged. The attacker/defender example records every camera run to
replays/field/, and --calibrate plays fixed moves (ScriptedMovesTactic:
rest, steps at 0.3/0.6/1 m/s, sideways, turns; a pause restarts the
interrupted move).

tools/robot_calibration.py measures each robot from such a log: loop
delay, speed gain and acceleration per axis (the sim's move model fitted
to the camera's positions), vision noise at rest, missed detections and
kick speed. On a synthetic robot it recovers delay within 10 ms,
acceleration within 15 % and gain within 1 %.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): match timeline in the Replay and Live tabs; 15x smaller replay payload

timeline.js draws tools/match_timeline.py's swimlanes in the browser:
the referee command band, one lane per friendly robot coloured by its
tactic with a strip where it had the ball, each robot's distance to the
ball, and the event markers. In the Replay tab it follows the cursor and
seeks on click or drag (30 s / 60 s / 3 min / whole match); its tactic
spans match match_timeline.tactic_changes exactly on a tournament match.
In the Live tab each streamed state extends the lanes, stamped with the
frame's game time (now sent with the state), keeping the last 5 minutes.

A 13-minute replay's frames were 108 MB of JSON (full 17-digit floats)
against a 16 MB .npz. Positions now go to the millimetre, velocities to
the centimetre, and responses over 64 KB are gzipped: 7.4 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): Play list of situations; possession band, switch ticks, shots

The Replay side panel's raw event and intention logs were hard to read. A Play list replaces
them: one row per situation (a restart going live or a side winning the ball, from
goal_ledger), with its third, length, shots and how it ended (the stoppage's reason, or who
won the ball and how). Click a row to seek; the raw intention log stays, folded.

The timeline gains a possession band, tactic switch ticks, shots and hover tooltips. The slow
analysis (ball losses, shots) is kept beside the replay in <stem>.timeline.json, and npz
frames are built straight from the columns, so a replay serves in ~3 s instead of ~16 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(refiners): an unseen ball rolls to a stop inside vision's area

Unseen, the ball filter carried the ball on at its last velocity, and the velocity refiner kept
that velocity alive from the guessed positions: a ball hidden by a robot drifted away, and one
that left vision's area flew off for good (an rsim attacker/defender run had it 600 m out after
15 minutes). Now it rolls on from where it was lost, slowing at BALL_ROLL_DECEL (0.29 m/s^2,
official Div B logs) until it stops, and never leaves the area vision accepts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* docs(examples): a test day's measurements: calibration twice, floor friction, kicks, occlusions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(examples): run as documented, `pixi run python examples/<name>.py`

utama_core is not installed in the pixi environment, so every example failed at its first
import when run by path, as their docstrings say to (the field-test example included). Each
now puts the repo root on sys.path first, as the scripts in tools/ do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): ball placement by right-click; phone layout; lighter Live page

- Right-click (long-press on a phone) on the Live field: ball placement to that spot by Yellow
  or Blue, as the old referee GUI's God Mode had. In a simulator the ball is moved there.
- Below 760 px wide the panels stack, the page scrolls and buttons are bigger.
- The field shows the 0.3 m run-off; a ball beyond the view is drawn hollow at its edge.
- States arrive at 60 Hz: the field and timeline draw once per screen frame, the side panel's
  text 4 times a second (it was rebuilt on every state, so a click could miss).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* perf(dashboard): send a replay's ticks as columns of whole numbers

One JSON object per tick, every key repeated, made a match 65 MB (7.4 MB gzipped) and took 4 s
to build. Columns of mm, cm/s and mrad with `seen` masks are 21 MB (4.3 MB gzipped) and build
in 1.5 s; the page rebuilds the same per-tick frames in about 0.2 s, so nothing else changes.
Pickled replays keep the old shape.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a client that stops reading no longer freezes the control loop

`notify` writes each state to every stream client on the control loop (`CustomReferee.step`),
on blocking sockets with no timeout. A client that stopped reading (a phone locked with the
page open, a laptop asleep, WiFi gone) filled its buffers and blocked the loop for good: no
radio commands, no HALT, no auto-HALT. Stream sockets are now non-blocking: once a client's
buffers are full its write fails at once, it is dropped and its browser reconnects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(runner): an auto-HALT is the command of the tick that forced it

The field check ran after the referee stepped, so the tick that forced HALT still carried the
command it replaced: pressing Force Start (or a placement) while a check failed sent one tick
of motion. Vision is now taken and judged before the referee steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): the auto-HALT banner clears once play resumes; HALT is drawn first

The runner kept the last auto-HALT's reason for good, so the red banner stayed up with the
robots playing, and the Live page, which skipped its 4 Hz panel throttle while a reason was
set, rebuilt the side panel on every state from then on. The reason now lasts as long as the
HALT it forced. The command and the banner are drawn first on every frame, before the field
and timeline, so neither waits on the throttle or fails with a drawing error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a columnar replay's last Play row ends at its last tick

The end was read from `frames`, which a columnar replay leaves empty since its ticks are sent
as columns, so the last row ended at 0 s and read "-29 s" with no shots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a robot's heading is mirrored with the view

With our goal on the right in a view that mirrors x (rsim, replays), the heading dash was drawn
from the raw orientation, so a robot facing +x pointed the wrong way. It now goes through the
same field-to-screen transform as positions. The test runs field_canvas.js in node with a 2D
context that records lines, skipped where node is missing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a dropped stream client is closed, so its browser reconnects

d9e6f2e dropped a client that stopped reading from the list but left its connection open:
its keep-alives kept it alive, so a phone woken up got keep-alives and no states, its page
frozen with the dot green, and a failed write could leave half a frame in the stream. A
client whose write fails is now shut down and its browser reconnects (after 0.5 s: the stream
sets `retry`). States and keep-alives, written from two threads, share a lock per client so
neither cuts into the other's frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a ball placement must be inside the field and the area in play

Any point on the Live page was accepted, the run-off and the half the camera does not see
included, so the placing robots could drive out of vision. The command handler now refuses a
target outside the field lines or, with a camera, outside the field check's area in play: a
400 with the reason, and nothing reaches the referee.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): Live shows refused commands and a stalled stream; Halt pinned on a phone

- A command the server refused (a placement outside the area in play) or never got shows
  under the buttons for 6 s; it was only logged to the browser console, so a Halt that did
  not go through looked the same as one that did.
- No state for half a second turns the dot grey and shows "no update for N s" beside the
  command: the page was showing the past with the dot green.
- On a phone, Halt floats in the bottom-right corner, in reach wherever the page is scrolled;
  it sat below the field, the timeline and the field check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): a held touch opens the ball-placement menu (iPhone)

iPhone Safari fires no contextmenu on a long press, so the menu never opened there. A touch
held still for 0.6 s opens it too; a click within 0.4 s of opening (the lifted finger) no
longer closes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(refiners): with smoothing off, a second ball loss starts where the ball was last seen

Smoothing off and imputation on, the ball filter runs only while the ball is unseen, so it
never heard the ball had been seen again: a second loss carried on the first one's roll from
where that one started. A seen ball now resets the filter when it is not run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
isaac0804 added a commit that referenced this pull request Oct 27, 2026
…al-robot examples, in-house vision plan (#149)

* docs: refresh the roadmap against the code, and the stale notes it points at

The roadmap still described the pre-evaluate.py evaluation plan, kept a
mirror_swap xfail that no longer exists, and listed several items that had
already shipped. Rewritten along its own convention (a resolved item becomes a
one-line pointer under "Done"), with the remaining items numbered in sequence:

- the strategy-evaluation items -> Done: evaluate.py and pool_v1 replaced the
  situation bench and ladder.py. The two genuinely open parts moved instead of
  disappearing: the CI stall gate into the stalls entry, the tactic contracts
  into the ball-holding entry.
- directional tube and Replay overlays -> Done; fpp mirror_swap -> Done: the
  0.52 m was the defense-area keep-out clamp, not a local planner minimum, and
  the xfail went in 5f97199.
- stalls -> the 2026-10-17 full round-robin figures; sim realism -> Phases 0-2
  built, with what remains named.
- bang-bang, trajsample, testing gaps and convergence revised for what has
  landed since; the unrecorded Sumatra findings (#4-7) and the shared-Sticky
  item removed (Sticky exists and is used).

Also fixes the stale notes that pointed into the roadmap: obstacles.py's class
docstring still described a growing circle, FORK_NOTES referenced two deleted
roadmap sections, sim_realism's status line read as if nothing had been built,
and testing_gaps #6 now records the one sim case for
ball_placement_interference.

* docs: fix the roadmap refresh's errors; cite roadmap entries by title

Corrections to the previous commit, checked against the code:
- stalls: 1731e90 fixed only the kick-off that waited the 15 s cap; the
  three COMMITTED_FROZEN stalls of the 2026-10-17 round-robin are not yet
  diagnosed (docs/strategies.md says the same).
- sim realism: P1's value model is fitted (passes 1-3) but not yet a better
  ranker than goals; only P2 is not started.
- Shared hysteresis helper: Sticky's users are PassAndShoot, ClearBall and
  PressAndContain; pickers.friendly_closer_to_ball keeps its own hysteresis.
- grsim entry: drop the note that FORK_NOTES points at a missing section
  (the previous commit fixed it); "like 10c" names its entry; pool_v1 holds
  the v3 ranges, v3_seed2 is the round-robin's seeded run of them.

The roadmap now asks to cite entries by title, since open items renumber.
Every comment, docstring and doc that cited a roadmap number used numbers
long gone ("roadmap item 15/16", "item 5", "item 1"); each now names its
entry. fastpathplanner_corner_test's docstring said seeds were pinned as
stuck; the list has been empty since #147.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(engine): skip the pinned goalkeeper when its robot is not on the field

A team with no robot of goalkeeper_id (default 0) crashed on its first tick
with KeyError in the goalkeeper tactic. Real tests often run without robot 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(tactics): hysteresis on the shot aim in LeadAndSupport and GiveAndGo

Against a centred keeper the two corners are near-equal, and the aim flipped
between them every tick; a lone carrier turned back and forth ~11 s before
shooting. Uses find_best_shot's switch margin, as _pass_and_score does.
Changes strategy code: cached matches rerun.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(examples): one attacker vs one defender on real robots or in sim

Field, camera half, vision/firmware IDs, IR trust and tactics in one config
block; operator-only referee driven from the dashboard; starts in HALT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(engine): no goalkeeper row in debug_status when its robot is absent

A team without the goalkeeper's robot showed a phantom goalkeeper (e.g. ID 15)
in the dashboard's tactics list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(run): field check on camera runs, auto-HALT on real robots

FieldCheck judges raw vision, the geometry packet and radio feedback: each
driven robot seen, under its ID, inside field_bounds; camera sending; field
size; radio still answering; unconfigured robots in play. The Live tab shows
the rows, dims everything outside field_bounds and draws the field as
ssl-vision sees it. On real robots any failing row forces HALT. The dashboard
opens on Live when a match is attached; Space only halts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(run): robot profiles: push without a kicker, gentle carry without a dribbler

RobotProfile(kicker, dribbler, ir) per robot, passed to StrategyRunner as
{yellow,blue}_robot_profiles and applied in every mode, so a test with a
broken robot can be rehearsed in a simulator. ProfiledRobotController wraps
the team's robot controller: a kick becomes a 0.3 s forward push at 1 m/s
(a stop command ends it); a dribble request without a dribbler never backs
up and is scaled to 0.5 m/s and 2 rad/s together. A robot without a ball
sensor is left out of the trusted-IR set. A profile for a robot that is not
driven, or ball sensors given both ways, stops the run at startup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(examples,dashboard): per-robot hardware and RULES switch; tidier Live page

The attacker/defender example takes one TestRobot per robot (vision ID,
firmware ID, RobotProfile) and RULES = "off" | "on" ("on": the human
referee profile, nothing auto-advances). The Live page lists only failing
and warning field-check rows, folds the passing ones into an "N ok" line,
hides the restart buttons when no rule sets up a restart, and drops the
robot list the field check already covers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(run,analysis): record field tests as SSL game logs; measure robots from them

StrategyRunner(game_log_path=...) records a camera run in the league's
game-log format: every vision packet as received, the referee's state,
the radio's bytes both ways and a run-info record (ID mappings,
profiles), all on this machine's clock. tools/convert_ssl_log.py reads
it unchanged. The attacker/defender example records every camera run to
replays/field/, and --calibrate plays fixed moves (ScriptedMovesTactic:
rest, steps at 0.3/0.6/1 m/s, sideways, turns; a pause restarts the
interrupted move).

tools/robot_calibration.py measures each robot from such a log: loop
delay, speed gain and acceleration per axis (the sim's move model fitted
to the camera's positions), vision noise at rest, missed detections and
kick speed. On a synthetic robot it recovers delay within 10 ms,
acceleration within 15 % and gain within 1 %.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): match timeline in the Replay and Live tabs; 15x smaller replay payload

timeline.js draws tools/match_timeline.py's swimlanes in the browser:
the referee command band, one lane per friendly robot coloured by its
tactic with a strip where it had the ball, each robot's distance to the
ball, and the event markers. In the Replay tab it follows the cursor and
seeks on click or drag (30 s / 60 s / 3 min / whole match); its tactic
spans match match_timeline.tactic_changes exactly on a tournament match.
In the Live tab each streamed state extends the lanes, stamped with the
frame's game time (now sent with the state), keeping the last 5 minutes.

A 13-minute replay's frames were 108 MB of JSON (full 17-digit floats)
against a 16 MB .npz. Positions now go to the millimetre, velocities to
the centimetre, and responses over 64 KB are gzipped: 7.4 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): Play list of situations; possession band, switch ticks, shots

The Replay side panel's raw event and intention logs were hard to read. A Play list replaces
them: one row per situation (a restart going live or a side winning the ball, from
goal_ledger), with its third, length, shots and how it ended (the stoppage's reason, or who
won the ball and how). Click a row to seek; the raw intention log stays, folded.

The timeline gains a possession band, tactic switch ticks, shots and hover tooltips. The slow
analysis (ball losses, shots) is kept beside the replay in <stem>.timeline.json, and npz
frames are built straight from the columns, so a replay serves in ~3 s instead of ~16 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(refiners): an unseen ball rolls to a stop inside vision's area

Unseen, the ball filter carried the ball on at its last velocity, and the velocity refiner kept
that velocity alive from the guessed positions: a ball hidden by a robot drifted away, and one
that left vision's area flew off for good (an rsim attacker/defender run had it 600 m out after
15 minutes). Now it rolls on from where it was lost, slowing at BALL_ROLL_DECEL (0.29 m/s^2,
official Div B logs) until it stops, and never leaves the area vision accepts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* docs(examples): a test day's measurements: calibration twice, floor friction, kicks, occlusions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(examples): run as documented, `pixi run python examples/<name>.py`

utama_core is not installed in the pixi environment, so every example failed at its first
import when run by path, as their docstrings say to (the field-test example included). Each
now puts the repo root on sys.path first, as the scripts in tools/ do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): ball placement by right-click; phone layout; lighter Live page

- Right-click (long-press on a phone) on the Live field: ball placement to that spot by Yellow
  or Blue, as the old referee GUI's God Mode had. In a simulator the ball is moved there.
- Below 760 px wide the panels stack, the page scrolls and buttons are bigger.
- The field shows the 0.3 m run-off; a ball beyond the view is drawn hollow at its edge.
- States arrive at 60 Hz: the field and timeline draw once per screen frame, the side panel's
  text 4 times a second (it was rebuilt on every state, so a click could miss).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* perf(dashboard): send a replay's ticks as columns of whole numbers

One JSON object per tick, every key repeated, made a match 65 MB (7.4 MB gzipped) and took 4 s
to build. Columns of mm, cm/s and mrad with `seen` masks are 21 MB (4.3 MB gzipped) and build
in 1.5 s; the page rebuilds the same per-tick frames in about 0.2 s, so nothing else changes.
Pickled replays keep the old shape.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a client that stops reading no longer freezes the control loop

`notify` writes each state to every stream client on the control loop (`CustomReferee.step`),
on blocking sockets with no timeout. A client that stopped reading (a phone locked with the
page open, a laptop asleep, WiFi gone) filled its buffers and blocked the loop for good: no
radio commands, no HALT, no auto-HALT. Stream sockets are now non-blocking: once a client's
buffers are full its write fails at once, it is dropped and its browser reconnects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(runner): an auto-HALT is the command of the tick that forced it

The field check ran after the referee stepped, so the tick that forced HALT still carried the
command it replaced: pressing Force Start (or a placement) while a check failed sent one tick
of motion. Vision is now taken and judged before the referee steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): the auto-HALT banner clears once play resumes; HALT is drawn first

The runner kept the last auto-HALT's reason for good, so the red banner stayed up with the
robots playing, and the Live page, which skipped its 4 Hz panel throttle while a reason was
set, rebuilt the side panel on every state from then on. The reason now lasts as long as the
HALT it forced. The command and the banner are drawn first on every frame, before the field
and timeline, so neither waits on the throttle or fails with a drawing error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a columnar replay's last Play row ends at its last tick

The end was read from `frames`, which a columnar replay leaves empty since its ticks are sent
as columns, so the last row ended at 0 s and read "-29 s" with no shots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a robot's heading is mirrored with the view

With our goal on the right in a view that mirrors x (rsim, replays), the heading dash was drawn
from the raw orientation, so a robot facing +x pointed the wrong way. It now goes through the
same field-to-screen transform as positions. The test runs field_canvas.js in node with a 2D
context that records lines, skipped where node is missing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a dropped stream client is closed, so its browser reconnects

d9e6f2e dropped a client that stopped reading from the list but left its connection open:
its keep-alives kept it alive, so a phone woken up got keep-alives and no states, its page
frozen with the dot green, and a failed write could leave half a frame in the stream. A
client whose write fails is now shut down and its browser reconnects (after 0.5 s: the stream
sets `retry`). States and keep-alives, written from two threads, share a lock per client so
neither cuts into the other's frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a ball placement must be inside the field and the area in play

Any point on the Live page was accepted, the run-off and the half the camera does not see
included, so the placing robots could drive out of vision. The command handler now refuses a
target outside the field lines or, with a camera, outside the field check's area in play: a
400 with the reason, and nothing reaches the referee.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): Live shows refused commands and a stalled stream; Halt pinned on a phone

- A command the server refused (a placement outside the area in play) or never got shows
  under the buttons for 6 s; it was only logged to the browser console, so a Halt that did
  not go through looked the same as one that did.
- No state for half a second turns the dot grey and shows "no update for N s" beside the
  command: the page was showing the past with the dot green.
- On a phone, Halt floats in the bottom-right corner, in reach wherever the page is scrolled;
  it sat below the field, the timeline and the field check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): a held touch opens the ball-placement menu (iPhone)

iPhone Safari fires no contextmenu on a long press, so the menu never opened there. A touch
held still for 0.6 s opens it too; a click within 0.4 s of opening (the lifted finger) no
longer closes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(refiners): with smoothing off, a second ball loss starts where the ball was last seen

Smoothing off and imputation on, the ball filter runs only while the ball is unseen, so it
never heard the ball had been seen again: a second loss carried on the first one's roll from
where that one started. A seen ball now resets the filter when it is not run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(engine): a tactic's overlay is cleared once it stops being traced

trace_if_changed only logs a change, so a key a tactic stopped tracing
(the tactic stopped running, or its phase ended) kept its last value and
the replay kept drawing it. Strategy.tick now records None for every key
not traced that tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): bigger robot labels, and a role label no longer covers the robot number

Labels scale with the robot (10 px at least) and have a halo so they read
over lines; a tactic's highlight label goes under the robot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): the replay shows the referee's match clock; a key for the referee band

The replay time keeps running through stoppages, so a 10 min match reads
12-13 min; the match clock beside it runs only in play, as the referee's
does. The legend now names the referee band's colours (a short STOP or
placement is too narrow to carry its name), and the ball row says how a
situation started ("from kickoff") rather than the restart's name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(dashboard): a command from the Live tab is stamped on the referee's clock

The page's commands were stamped with the wall clock, but in rsim the referee
runs on sim time: one button press put its match clock (and the STOP and
free-kick timers) 1.8e9 s out, shown as "29878093:22 left". They now take
the time of the referee's last step (CustomReferee.now).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(dashboard): choose or restart each side's strategy from the Live tab

StrategyRunner.replace_strategy runs a new strategy for a side from the next
tick, only while halted; vision, the radio and the dashboard carry on. The
script lists the choices (dashboard/views/strategy.py) and the Live tab shows
a list and Run per side; Run on the one running restarts it. The field
example offers its attacker and defender tactics.

The example's robot now holds the shot line while its tactic does not apply:
press applies only with an opponent near the ball, and picking it anyway made
the kernel raise and stop the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* docs: the Live tab's Strategy panel

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(run,dashboard): an operator's HALT lasts in the simulators; a taken dashboard port fails at start

In rsim and grSim the runner resumes a HALT after 5 s, for tournaments with
nobody to end a HALT the rules call. It resumed the operator's Halt too, and
the field example's starting HALT, so a grSim test was playing before anyone
pressed Start and the Strategy panel had 5 s to act. A HALT called from the
dashboard (or a run started halted with the dashboard attached) now lasts
until the operator ends it; the rules' HALTs still resume.

A dashboard whose port was taken failed in its server thread and the match
ran on without one, the open page showing the other dashboard. It now
raises when started.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(run): on camera runs our robots stop at the edge of the area in play

A tactic chasing a ball out of the camera's half drove its robot after it
(0.9 m past the half-way line in a grSim test of real_attacker_defender),
out of sight of vision. The planners' walls at field_bounds are obstacles
to plan around, not a limit, and the field check only halts once a robot
is 0.3 m out. FencedRobotController sits under the strategy in grSim and
real mode and caps each robot's speed towards each edge to what it can
stop from before the line (2 m/s^2, 0.1 s delay); speed away from an edge
is never limited, so a robot outside drives back in. In grSim, a ball
rolled out repeatedly for 25 s: 0.52 m outside without it, 0.001 m with.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* docs,tools: the real-robot test-day procedure; a vision check

docs/real_robot_setup.md: ssl-vision on its own laptop, WSL mirrored
networking and the Hyper-V firewall rule for vision, the radio through
usbipd by hardware ID with auto-attach, teleop, and the field check, with
the failures met on the first setup (2026-10-22) and their causes. It
replaces setup_external.md's short WSL section.

tools/vision_check.py says whether ssl-vision's multicast reaches this
machine, per camera, with no radio or runner. tools/ssl-vision-lab-field.patch
adds our field as a preset to ssl-vision.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* examples(real_attacker_defender): the lab field, 3 m x 2.25 m

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* tools(vision_check): each robot and ball where each camera puts it, and the gap

Two cameras over the whole field disagreed on robot positions (graphicalClient
showed them jumping between two spots); the gap in mm shows whether a
calibration change helped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(vision): weight each camera by how steeply it sees a robot; drop one that disagrees

Two cameras over the whole lab field (2026-10-22), each behind a goal,
put a robot 17 cm apart at the far end; CameraCombiner averaged them, so
the robot sat 9 cm off both and jumped 9 cm whenever a camera missed a
frame. The receiver now keeps each camera's position from ssl-vision's
geometry calibrations and stamps it on that camera's frames; the combiner
weights each detection by cos^4 of its camera's view angle and drops one
further than CAMERA_AGREEMENT (0.1 m) from the best-placed camera's.
Without positions (rsim, grSim, an uncalibrated camera) every camera
counts the same, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* examples: real_score_goal, one robot alone takes the ball and scores

For a test day with a single robot ready. Field and camera view come from
real_attacker_defender.py so the two agree; starts in HALT with the field
check and dashboard as there. Scores in rsim within 5 s of Force Start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(run): a team alone in real mode tracks only its own mapped robots, whatever its colour

The roster allowlists assumed our team was yellow: with our blue robot
alone (real_score_goal.py, 2026-10-22) blue went unfiltered and yellow was
filtered, so misread patterns (blue 1, blue 5 beside our blue 2) became our
robots and lived on in the Kalman filter as ghosts. A colour we drive is now
filtered to its mapping's IDs; the opponent's colour without an opponent
strategy stays unfiltered. The field check still reads raw vision, so it
keeps listing such detections as 'not configured'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* examples: real_dribble_and_shoot; fix(dribble): letting go backs away instead of waiting

DribbleTactic stood still after releasing the ball until it was 13 cm
away; a ball that stopped at 11 cm froze the robot for good (rsim,
2026-10-22). It now backs off at 0.2 m/s until the ball is clear.

real_dribble_and_shoot: one robot dribbles a lap, then LeadAndSupportTactic
shoots; real_score_goal's build_runner takes the tactics, offered in the
Strategy panel. In rsim the robot still stalls on the second segment with
the ball (the planner's command shrinks to ~0, DribbleTactic's known rsim
issue); untested on a real robot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(vision): fuse the ball across cameras like the robots, and keep a one-camera ball at its robot

The robot was averaged across cameras while each camera's ball stayed separate (merged only
within 5 cm) and one was picked, so with the cameras ~6 cm apart the ball at the dribbler came
out inside the robot. Ball detections within BALL_MERGE_THRESHOLD (now 0.2 m) are fused by the
robots' rule, and a ball one camera alone sees (the robot hides it from the other) is moved by
that camera's offset at the nearest robot it also sees (BALL_ROBOT_SHIFT_RANGE).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* tools(vision_check): --guided checks each camera against 9 known spots and saves the baseline

Cameras that agree with each other can both be wrong. The ball (or a robot, facing +x) goes on
the corners, edge midpoints and centre of the announced field; each camera's error is printed
per spot and as mean, worst and shift, and saved to replays/field/. A stray ball detection
far from the spot (camera 0 sees one 824 mm away) is left out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* docs: plan an in-house vision stack for lab days, with the experiments that decide it

The 2026-10-22 test day's vision findings, the options considered, the design
(per-session tag calibration, tags on robots, ball inside the field mask, SSL
packets out), two experiments with pass marks, and the order of work. Linked
from the roadmap and the docs index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(run): the fence holds only edges inside the field, and fences a kicker-less push

With the cameras on the whole field (and in grSim by default) the fence was the
touch and goal lines: a robot could not cross them to fetch a ball out of play,
so a free kick or placement there never completed, and the keeper could not
step into its goal mouth. Only edges inside the field (the half-way line on a
half-field run) are fenced now; none when the cameras see it all.

The fence also wrapped the profile layer, which turns a kicker-less robot's
kick into a 0.3 m push the fence never saw. The profile is outside it now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(vision_check): --guided measures each spot from packets after Enter

Packets queued while the operator carried the robot to the next spot were read
first, so the previous spot (1.1-1.5 m away) and the carry were averaged into
the spot's error and the saved baseline. The socket is drained on Enter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(vision): a camera at height 0 counts as one whose position is unknown

A camera not calibrated yet reports height 0, which gave every robot it sees a
view-angle weight of 0: a robot only it saw divided by zero in CameraCombiner.
Also corrects the comment: grSim sends camera positions too, so the weighting
is active there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(vision): a camera that loses the robot for a frame keeps the ball at its dribbler

When camera 1 kept the ball at the dribbler but lost the robot for a frame or
two, the robot came from camera 0 alone, 8.5 cm from camera 1's view, and the
ball was not shifted: it sat inside the robot in 95 of 3,971 frames of
20261022_211142_real_score_goal.log. CameraCombiner now remembers each
camera's gap to the others at each robot (BALL_ROBOT_OFFSET_MEMORY, 0.5 s), and
places a robot a camera has just lost at another camera's detection plus that
gap. The refiner keeps one combiner across frames. Replayed, that log has no
ball inside the robot; the nearest is 94 mm, as in camera 1's own view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(refiners): the ball is one in the field or a goal, nearest the last, not the most confident

2026-10-22: camera 0 saw up to four steady "balls" 15 cm past the +y touch line,
as confident as the real one. The most confident ball, ties going to list order,
was a fake 22% of the time, and the ball flickered between them. PositionRefiner
now takes a ball inside the field lines or a goal when there is one, then the
one nearest the last ball; with no last ball, the most confident. Replayed over
the day's logs, no log has more ball jumps of over 0.3 m in one frame; in
20261022_213347_real_score_goal.log they fall from 5,439 to 4, and frames
with the ball off the field from 7,317 to 88.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* docs(sim_realism): first measurements of our robot from the 2026-10-22 field logs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* docs(in_house_vision): the team chose the in-house stack; plan in order with pass marks

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* docs(in_house_vision): how calibration tells floor and raised tags apart, a session, the code

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* feat(game_log): record which code ran, operator and strategy-change notes, and the world beside the log

RUN_INFO now holds the command line, the git commit and whether the tree was dirty, and the
field bounds. A NOTE record (103) carries notes typed in the Live tab and the runner's own
strategy changes (and dropped ones). A run with a game log also writes the filtered world as a
columnar replay beside it, saved once at close: a checkpoint took 385 ms ten minutes in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(strategy_view): show running only what the runner swapped in; queue a strategy with its name

The Strategy panel showed a change as running as soon as it was queued, so one dropped because
play restarted first still showed. It now shows StrategyRunner.strategy_names, set at the swap.
The dashboard thread queued the strategy and its name in two writes; a swap between them noted
the class name and left the chosen name to label the next change. One entry now holds both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TJGHdb8Dht67hn4Lohndf5

* fix(vision): preserve plausible nearby ball observations (#150)

* fix(vision): preserve plausible nearby ball observations

Prefer plausible nearby alternatives before per-camera confidence selection,
and keep each camera from overweighting close duplicate observations. Preserve
observed ball/robot geometry when independent smoothing would move the ball
inside its robot, while retaining the existing estimate during camera loss.

Add proximity-boundary, confidence, field/goal, camera-loss and minimized
real-log regression tests.

* fix(vision): retain valid boundary observations and reset dimensions

Filter original robot detections by vision bounds before averaging same-camera
repeats, and retain field dimensions when resetting the camera combiner.

Add 28 regression cases covering both teams, all four vision boundaries,
position bias, and repeated resets with smoothing enabled and disabled.
All 168 refiner tests pass; formatting and lint checks pass.

* fix(ci): separate push and pull request concurrency groups

Include the event type in each branch's concurrency group so the push and
pull-request checks cannot cancel each other on the same commit. Continue
canceling superseded runs for each event and branch.

* fix(vision): keep single-camera ticks in fused frame instead of post-Kalman clamp

---------

Co-authored-by: isaac0804 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Fred Huang <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working release:patch Fixes and small tweaks to main.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant